Skip to content

Comments

support multiple API keys per team#5

Merged
9cb14c1ec0 merged 3 commits intomasterfrom
api-key
Feb 19, 2026
Merged

support multiple API keys per team#5
9cb14c1ec0 merged 3 commits intomasterfrom
api-key

Conversation

@9cb14c1ec0
Copy link
Owner

@9cb14c1ec0 9cb14c1ec0 commented Feb 19, 2026

Move api_key_hash/api_key_prefix from teams table into a new api_keys table with FK to team, enabling multiple concurrent keys per team. Add CRUD endpoints for key management (create/list/delete) and a migration that carries existing keys over. The frontend replaces the single regenerate-key button with a full key management dialog supporting both auto-generated and manually provided keys.

Summary by CodeRabbit

Release Notes

  • New Features
    • Teams can now have multiple API keys instead of a single shared key
    • New API key management interface: generate, add custom keys, and revoke access as needed
    • Updated dashboard to display API key count per team
    • Admin panel now includes dedicated API keys management section with full CRUD capabilities

Move api_key_hash/api_key_prefix from teams table into a new api_keys
table with FK to team, enabling multiple concurrent keys per team.
Add CRUD endpoints for key management (create/list/delete) and a
migration that carries existing keys over. The frontend replaces the
single regenerate-key button with a full key management dialog
supporting both auto-generated and manually provided keys.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Feb 19, 2026

Warning

Rate limit exceeded

@9cb14c1ec0 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 20 minutes and 4 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

This PR refactors API key management from single-key-per-team (stored in Team model) to multi-key support via a dedicated ApiKey model. It introduces CRUD endpoints (list, create, delete), new schemas for API key responses, a database migration, and updates frontend components to display and manage multiple API keys.

Changes

Cohort / File(s) Summary
API Key Model & Migration
backend/app/models/api_key.py, backend/migrations/004_api_keys_table.sql
Introduces new ApiKey model with id, team FK, label, api_key_hash (unique, indexed), api_key_prefix, created_at. Provides generate_api_key(), hash_api_key(), and get_team_by_api_key() utilities. Migration creates api_keys table, migrates existing keys from teams, and drops API key columns from teams table.
Team Model Refactoring
backend/app/models/team.py, backend/app/models/__init__.py
Removes api_key_hash, api_key_prefix fields and related methods (generate_api_key, hash_api_key, get_by_api_key) from Team. Adds reverse relation api_keys linking to ApiKey. Updates exports to include ApiKey model.
API Schemas & Types
backend/app/schemas/team.py, backend/app/schemas/__init__.py
Replaces single api_key_prefix field with api_keys list in TeamResponse. Removes TeamWithKey, adds ApiKeyResponse, ApiKeyWithSecret, ApiKeyCreate, and TeamCreateResponse. Updates public exports accordingly.
Admin API Endpoints
backend/app/api/admin.py
Refactors team endpoints to use new _team_response() builder with prefetched API keys. Replaces single-key regenerate endpoint with three new endpoints: list_api_keys (GET), create_api_key (POST with auto-generation or manual entry), delete_api_key (DELETE). Updates team creation to generate and return default API key.
API Key Validation
backend/app/api/deps.py
Updates get_team_from_api_key to use ApiKey.get_team_by_api_key() instead of Team.get_by_api_key() for API key lookups.
Frontend API Client
frontend/src/api/client.ts
Adds ApiKey and ApiKeyWithSecret interfaces. Replaces api_key_prefix with api_keys array in Team. Replaces TeamWithKey with TeamCreateResponse extending Team.
Frontend Store
frontend/src/stores/teams.ts
Replaces regenerateApiKey with three new methods: getApiKeys(), createApiKey() (supports generation or manual entry with optional label), deleteApiKey(). Updates exports and type imports.
Frontend Views
frontend/src/views/Dashboard.vue, frontend/src/views/admin/Teams.vue
Dashboard displays count of API keys instead of single prefix. Teams admin view replaces regenerate action with new API Keys management dialog supporting list, create (generate/manual modes), and delete operations with copy-to-clipboard and refresh functionality.

Sequence Diagram

sequenceDiagram
    participant Admin as Admin User
    participant API as Admin API
    participant DB as Database
    participant Auth as Auth Validator

    rect rgba(0, 100, 200, 0.5)
    Note over Admin,DB: Team Creation Flow (New)
    Admin->>API: POST /teams (TeamCreate)
    API->>DB: Create Team
    API->>API: generate_api_key()
    API->>DB: Store in api_keys table
    API-->>Admin: TeamCreateResponse with api_keys[0] + secret
    end

    rect rgba(100, 0, 200, 0.5)
    Note over Admin,DB: API Key Validation (Updated)
    Admin->>Auth: API Request (x-api-key header)
    Auth->>DB: Query ApiKey by api_key_hash
    DB-->>Auth: Return Team via FK relation
    Auth-->>Admin: Proceed with request
    end

    rect rgba(0, 200, 100, 0.5)
    Note over Admin,DB: API Key Management (New)
    Admin->>API: GET /teams/{team_id}/api-keys
    API->>DB: Query ApiKey by team_id
    DB-->>API: list[ApiKeyResponse]
    API-->>Admin: Display key list

    Admin->>API: POST /teams/{team_id}/api-keys (create)
    API->>API: generate_api_key() or hash user key
    API->>DB: Store in api_keys
    API-->>Admin: ApiKeyWithSecret (includes secret)

    Admin->>API: DELETE /teams/{team_id}/api-keys/{key_id}
    API->>DB: Delete ApiKey record
    DB-->>API: Success
    API-->>Admin: Confirm deletion
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 A curious rabbit hops with glee,
Multiple keys now flow so free!
From single lock to many bright,
The warren's doors shine with new light ✨
Team by team, key by key—security takes flight!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'support multiple API keys per team' accurately describes the primary change in the pull request, which moves from single API key per team to supporting multiple keys through a new api_keys table and CRUD endpoints.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch api-key

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (11)
frontend/src/views/admin/Teams.vue (3)

18-25: Minor: comma separator is inside the <code> tag.

The trailing , on line 22 renders inside the <code> element, so the comma inherits monospace/code styling. Consider moving the separator outside the <code> tag for cleaner visual output.

Proposed fix
-          <span v-else>
-            <code v-for="(key, i) in item.api_keys" :key="key.id">
-              {{ key.api_key_prefix }}...{{ i < item.api_keys.length - 1 ? ', ' : '' }}
-            </code>
-          </span>
+          <span v-else>
+            <template v-for="(key, i) in item.api_keys" :key="key.id">
+              <code>{{ key.api_key_prefix }}...</code>{{ i < item.api_keys.length - 1 ? ', ' : '' }}
+            </template>
+          </span>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/views/admin/Teams.vue` around lines 18 - 25, The comma separator
is being rendered inside the <code> for the API key list in the template
`#item.api_keys`; update the template so the <code> element contains only the key
text (key.api_key_prefix) and render the separator (', ') outside the <code>
element (e.g. conditionally render a text node or span after the <code> when i <
item.api_keys.length - 1) to keep the comma styling consistent and avoid
monospace formatting leaking into the separator.

355-357: copyKey has no user feedback or error handling.

navigator.clipboard.writeText can fail (e.g., in non-secure contexts) and provides no visual confirmation to the user on success. A brief snackbar/toast or try-catch would improve UX.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/views/admin/Teams.vue` around lines 355 - 357, Update the
copyKey function to await navigator.clipboard.writeText(newApiKey.value) inside
a try/catch, and on success call the component's user-feedback routine (e.g.,
this.$toast.success, showSnackbar, or emit a "notify" event) to show a brief
confirmation; on failure catch the error, show an error notification (with the
error message) and optionally fall back to a safe clipboard approach (selecting
the text and using document.execCommand('copy')) when navigator.clipboard is
unavailable. Ensure you reference and use the existing copyKey function and
newApiKey.value when implementing the changes.

408-417: Key revocation lacks a confirmation prompt.

revokeKey immediately deletes the API key on click with no confirmation. Since this is a destructive and irreversible action (active integrations would break), consider adding a confirmation dialog similar to the team delete flow.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/views/admin/Teams.vue` around lines 408 - 417, The revokeKey
function currently deletes an API key immediately; wrap its destructive
operation in a user confirmation step: before calling api.delete in revokeKey,
open the same confirmation dialog/modal used by the team delete flow (or use a
simple window.confirm) and only proceed with the API call, teamKeys update, and
fetchTeams() when the user confirms; if the user cancels, return without making
any changes. Ensure you reference revokeKey, selectedTeam, teamKeys, and
fetchTeams so the dialog gating is applied exactly where the deletion is
triggered.
backend/migrations/004_api_keys_table.sql (2)

17-23: Redundant index on api_key_hash.

Line 17 declares api_key_hash VARCHAR(255) NOT NULL UNIQUE, which in PostgreSQL implicitly creates a unique index. The explicit CREATE INDEX idx_api_keys_hash on line 23 adds a second, non-unique index on the same column. It's not harmful but wastes disk space and write overhead.

Remove the redundant index
     CREATE INDEX idx_api_keys_team_id ON api_keys(team_id);
-    CREATE INDEX idx_api_keys_hash ON api_keys(api_key_hash);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/migrations/004_api_keys_table.sql` around lines 17 - 23, The schema
defines api_key_hash as UNIQUE on the api_keys table, which already creates a
unique index, so remove the redundant explicit CREATE INDEX idx_api_keys_hash ON
api_keys(api_key_hash); statement and keep the UNIQUE constraint (api_key_hash
VARCHAR(255) NOT NULL UNIQUE) intact to avoid duplicate indexing and unnecessary
disk/write overhead.

4-10: Partial-failure edge case in idempotency guard.

The guard checks only for table existence. If the migration partially completes (e.g., table created but INSERT or ALTER TABLE DROP COLUMN fails), a re-run will skip everything, leaving the migration in an inconsistent state. This is acceptable if the migration runner wraps the entire script in a transaction (which is typical), but worth confirming.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/migrations/004_api_keys_table.sql` around lines 4 - 10, The
idempotency guard only checks for the existence of the api_keys table which can
hide partial failures (table created but later INSERTs/ALTERs failed); update
the migration to ensure atomicity by wrapping the entire migration in a
transaction (BEGIN/COMMIT) or enhance the guard to check for a definitive
migration marker such as specific columns, constraints or a version row (e.g.,
verify required columns/constraints exist on api_keys or a migration_version
table entry) before skipping; refer to the api_keys table and the ALTER/INSERT
steps in this file when implementing the transaction wrapper or the more
specific existence checks.
backend/app/schemas/team.py (2)

50-58: TeamCreateResponse duplicates TeamResponse fields.

TeamCreateResponse repeats all fields from TeamResponse plus adds api_key. Inheriting from TeamResponse would reduce duplication and keep the two in sync.

Inherit from TeamResponse
-class TeamCreateResponse(BaseModel):
+class TeamCreateResponse(TeamResponse):
     """Response when creating a team - includes the first API key with secret."""
-    id: UUID
-    name: str
-    api_keys: list[ApiKeyResponse]
-    retention_days: int | None
-    created_at: datetime
-    updated_at: datetime
     api_key: str
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/schemas/team.py` around lines 50 - 58, TeamCreateResponse
duplicates all fields from TeamResponse; change TeamCreateResponse to inherit
from TeamResponse (e.g., class TeamCreateResponse(TeamResponse):) and remove the
duplicated attributes (id, name, api_keys, retention_days, created_at,
updated_at) leaving only the additional api_key field so the response extends
TeamResponse and stays in sync with it; ensure ApiKeyResponse and typing for
api_key remain correct.

33-35: Consider validating manually provided API keys.

ApiKeyCreate.api_key accepts any string with no minimum length or character constraints. A user could submit a trivially short or weak key (e.g., "a"), which would be stored and used for authentication. Adding a minimum length validator would mitigate this.

Add minimum length validation
+from pydantic import BaseModel, field_validator
+
 class ApiKeyCreate(BaseModel):
     label: str = ""
     api_key: str | None = None
+
+    `@field_validator`("api_key")
+    `@classmethod`
+    def validate_api_key(cls, v: str | None) -> str | None:
+        if v is not None and len(v) < 20:
+            raise ValueError("API key must be at least 20 characters long")
+        return v
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/schemas/team.py` around lines 33 - 35, ApiKeyCreate.api_key
currently allows any string; add a minimum-length validation so manually
provided API keys are not trivially short — modify the ApiKeyCreate model to
enforce a min length (e.g., 32) for api_key either by changing the field type to
a pydantic constrained string (constr(min_length=32)) or by adding a
`@validator`("api_key") that returns the value only if value is None or len(value)
>= 32 and raises a ValueError otherwise; update the ApiKeyCreate.api_key
annotation accordingly and include a clear error message in the raised
exception.
backend/app/models/api_key.py (1)

32-39: Consider adding a return type annotation.

get_team_by_api_key returns Optional[Team] but the signature lacks a type hint. Adding it would improve IDE support and static analysis.

Add return type hint
+    from __future__ import annotations
+    from typing import TYPE_CHECKING
+    if TYPE_CHECKING:
+        from app.models.team import Team
+
     `@classmethod`
-    async def get_team_by_api_key(cls, api_key: str):
+    async def get_team_by_api_key(cls, api_key: str) -> "Team | None":
         """Find a team by API key."""
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/models/api_key.py` around lines 32 - 39, Add an explicit return
type to the get_team_by_api_key method: annotate it as -> Optional[Team]. Import
Optional from typing (or typing.Optional) and ensure Team is imported or
referenced correctly (e.g., from .team import Team or using a forward reference
string "Team" if necessary) and update the signature of async def
get_team_by_api_key(cls, api_key: str) to include the new return type.
backend/app/api/admin.py (3)

98-125: _team_response helper is unused in list_teams.

list_teams builds TeamResponse inline (with prefetch_related, which is the right call for batch operations), while get_team and update_team use _team_response which issues a separate query. The helper is fine for single-team endpoints, but consider either removing it and inlining everywhere, or using prefetch_related inside _team_response too for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/api/admin.py` around lines 98 - 125, The helper _team_response is
unused by list_teams and currently always issues its own ApiKey query (causing
inconsistency/N+1 risk); modify _team_response(team: Team) so it first checks if
the Team instance already has a prefetched .api_keys attribute and uses those
keys to build ApiKeyResponse objects, and only falls back to awaiting
ApiKey.filter(team=team).all() when .api_keys is missing; keep list_teams as-is
(it prefetches) and ensure get_team/update_team can continue to call
_team_response unchanged.

254-262: Deleting the last API key leaves a team with no key.

There's no guard preventing deletion of a team's only remaining API key, which would silently break all API-key-authenticated ingestion for that team. Consider either warning or preventing this.

Guard against deleting the last key
     key = await ApiKey.filter(id=key_id, team_id=team_id).first()
     if key is None:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")

+    remaining = await ApiKey.filter(team_id=team_id).count()
+    if remaining <= 1:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Cannot delete the last API key for a team",
+        )
+
     await key.delete()
     return {"message": "API key revoked"}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/api/admin.py` around lines 254 - 262, In delete_api_key, prevent
revoking the team's last API key by checking ApiKey entries for the given team
before deletion: use ApiKey.filter(team_id=team_id).count() (or fetch all and
len) and if the count is 1 raise an HTTPException (400/409) with a clear message
instead of deleting; otherwise proceed to delete the key fetched by
ApiKey.filter(id=key_id, team_id=team_id).first() as currently implemented.

222-227: Prefix for manually provided keys leaks up to 10 characters in plaintext.

For auto-generated keys the stored api_key_prefix is the non-secret sl_XXXX portion, but for manually provided keys it's full_key[:10], which is the beginning of the actual secret. If the key has low entropy or a recognizable structure, this prefix narrows the search space. Consider documenting this trade-off or letting the user supply their own prefix.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/api/admin.py` around lines 222 - 227, The current branch sets
prefix = full_key[:10] for manually provided keys which leaks up to 10 plaintext
characters; change this to either accept a user-supplied non-secret prefix
(check for data.api_key_prefix) or derive a non-revealing prefix from the key
hash (e.g., use ApiKey.hash_api_key(full_key) and take a truncated portion of
the hex hash) and store that in api_key_prefix instead of slicing the raw key;
update the logic around full_key, ApiKey.hash_api_key and prefix to prefer
data.api_key_prefix, otherwise compute a hash-derived prefix so no plaintext
secret characters are stored.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/app/api/admin.py`:
- Around line 215-251: The create_api_key endpoint currently does a pre-check
for duplicate api_key_hash but can still hit a UNIQUE constraint race; wrap the
ApiKey.create call in a try/except that catches the database IntegrityError
(from your ORM/DB driver), and on IntegrityError translate it into a
HTTPException with status_code 400 and a clear message like "This API key
already exists"; keep the existing pre-check but ensure the except path handles
the concurrent-insert case for ApiKey.create so duplicate hash insertion doesn't
produce a raw 500.

In `@frontend/src/stores/teams.ts`:
- Around line 45-60: The Teams store defines getApiKeys, createApiKey, and
deleteApiKey but Teams.vue still calls api.get/post/delete directly (in
openKeysDialog, addKey, revokeKey); refactor Teams.vue to import and call these
store functions instead of duplicating the HTTP calls: replace the direct
api.get(`/admin/teams/${teamId}/api-keys`) with getApiKeys(teamId), the api.post
call with createApiKey(teamId, label, apiKey), and the api.delete call with
deleteApiKey(teamId, keyId); keep existing async/await, error handling, and
response handling in openKeysDialog/addKey/revokeKey so returned values and
types (ApiKey / ApiKeyWithSecret) are used unchanged.

In `@frontend/src/views/admin/Teams.vue`:
- Around line 378-406: In addKey(), prevent the silent-generation bug by adding
two small checks: if addKeyMode.value === 'manual' and newKeyForm.api_key is
empty, abort (set addingKey false and return or surface validation) so we don't
submit an empty API key; and after the POST, show the secret dialog whenever the
backend returns a generated secret (i.e. if response.data.api_key exists) by
assigning newApiKey.value = response.data.api_key and setting keyDialog.value =
true (instead of only checking addKeyMode === 'generate'). Update the addKey
function logic around the request and response handling to enforce the
manual-mode validation and to show the secret when response.data.api_key is
present.

---

Nitpick comments:
In `@backend/app/api/admin.py`:
- Around line 98-125: The helper _team_response is unused by list_teams and
currently always issues its own ApiKey query (causing inconsistency/N+1 risk);
modify _team_response(team: Team) so it first checks if the Team instance
already has a prefetched .api_keys attribute and uses those keys to build
ApiKeyResponse objects, and only falls back to awaiting
ApiKey.filter(team=team).all() when .api_keys is missing; keep list_teams as-is
(it prefetches) and ensure get_team/update_team can continue to call
_team_response unchanged.
- Around line 254-262: In delete_api_key, prevent revoking the team's last API
key by checking ApiKey entries for the given team before deletion: use
ApiKey.filter(team_id=team_id).count() (or fetch all and len) and if the count
is 1 raise an HTTPException (400/409) with a clear message instead of deleting;
otherwise proceed to delete the key fetched by ApiKey.filter(id=key_id,
team_id=team_id).first() as currently implemented.
- Around line 222-227: The current branch sets prefix = full_key[:10] for
manually provided keys which leaks up to 10 plaintext characters; change this to
either accept a user-supplied non-secret prefix (check for data.api_key_prefix)
or derive a non-revealing prefix from the key hash (e.g., use
ApiKey.hash_api_key(full_key) and take a truncated portion of the hex hash) and
store that in api_key_prefix instead of slicing the raw key; update the logic
around full_key, ApiKey.hash_api_key and prefix to prefer data.api_key_prefix,
otherwise compute a hash-derived prefix so no plaintext secret characters are
stored.

In `@backend/app/models/api_key.py`:
- Around line 32-39: Add an explicit return type to the get_team_by_api_key
method: annotate it as -> Optional[Team]. Import Optional from typing (or
typing.Optional) and ensure Team is imported or referenced correctly (e.g., from
.team import Team or using a forward reference string "Team" if necessary) and
update the signature of async def get_team_by_api_key(cls, api_key: str) to
include the new return type.

In `@backend/app/schemas/team.py`:
- Around line 50-58: TeamCreateResponse duplicates all fields from TeamResponse;
change TeamCreateResponse to inherit from TeamResponse (e.g., class
TeamCreateResponse(TeamResponse):) and remove the duplicated attributes (id,
name, api_keys, retention_days, created_at, updated_at) leaving only the
additional api_key field so the response extends TeamResponse and stays in sync
with it; ensure ApiKeyResponse and typing for api_key remain correct.
- Around line 33-35: ApiKeyCreate.api_key currently allows any string; add a
minimum-length validation so manually provided API keys are not trivially short
— modify the ApiKeyCreate model to enforce a min length (e.g., 32) for api_key
either by changing the field type to a pydantic constrained string
(constr(min_length=32)) or by adding a `@validator`("api_key") that returns the
value only if value is None or len(value) >= 32 and raises a ValueError
otherwise; update the ApiKeyCreate.api_key annotation accordingly and include a
clear error message in the raised exception.

In `@backend/migrations/004_api_keys_table.sql`:
- Around line 17-23: The schema defines api_key_hash as UNIQUE on the api_keys
table, which already creates a unique index, so remove the redundant explicit
CREATE INDEX idx_api_keys_hash ON api_keys(api_key_hash); statement and keep the
UNIQUE constraint (api_key_hash VARCHAR(255) NOT NULL UNIQUE) intact to avoid
duplicate indexing and unnecessary disk/write overhead.
- Around line 4-10: The idempotency guard only checks for the existence of the
api_keys table which can hide partial failures (table created but later
INSERTs/ALTERs failed); update the migration to ensure atomicity by wrapping the
entire migration in a transaction (BEGIN/COMMIT) or enhance the guard to check
for a definitive migration marker such as specific columns, constraints or a
version row (e.g., verify required columns/constraints exist on api_keys or a
migration_version table entry) before skipping; refer to the api_keys table and
the ALTER/INSERT steps in this file when implementing the transaction wrapper or
the more specific existence checks.

In `@frontend/src/views/admin/Teams.vue`:
- Around line 18-25: The comma separator is being rendered inside the <code> for
the API key list in the template `#item.api_keys`; update the template so the
<code> element contains only the key text (key.api_key_prefix) and render the
separator (', ') outside the <code> element (e.g. conditionally render a text
node or span after the <code> when i < item.api_keys.length - 1) to keep the
comma styling consistent and avoid monospace formatting leaking into the
separator.
- Around line 355-357: Update the copyKey function to await
navigator.clipboard.writeText(newApiKey.value) inside a try/catch, and on
success call the component's user-feedback routine (e.g., this.$toast.success,
showSnackbar, or emit a "notify" event) to show a brief confirmation; on failure
catch the error, show an error notification (with the error message) and
optionally fall back to a safe clipboard approach (selecting the text and using
document.execCommand('copy')) when navigator.clipboard is unavailable. Ensure
you reference and use the existing copyKey function and newApiKey.value when
implementing the changes.
- Around line 408-417: The revokeKey function currently deletes an API key
immediately; wrap its destructive operation in a user confirmation step: before
calling api.delete in revokeKey, open the same confirmation dialog/modal used by
the team delete flow (or use a simple window.confirm) and only proceed with the
API call, teamKeys update, and fetchTeams() when the user confirms; if the user
cancels, return without making any changes. Ensure you reference revokeKey,
selectedTeam, teamKeys, and fetchTeams so the dialog gating is applied exactly
where the deletion is triggered.

Comment on lines +215 to +251
@router.post("/teams/{team_id}/api-keys", response_model=ApiKeyWithSecret, status_code=status.HTTP_201_CREATED)
async def create_api_key(admin: AdminUser, team_id: UUID, data: ApiKeyCreate):
"""Create a new API key for a team. If api_key is provided, use it; otherwise auto-generate."""
team = await Team.filter(id=team_id).first()
if team is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team not found")

if data.api_key:
# Manually provided key
full_key = data.api_key
key_hash = ApiKey.hash_api_key(full_key)
# Derive prefix from the key (first 10 chars or less)
prefix = full_key[:10] if len(full_key) > 10 else full_key

# Check for duplicate hash
existing = await ApiKey.filter(api_key_hash=key_hash).first()
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="This API key already exists")
else:
# Auto-generate
full_key, key_hash, prefix = ApiKey.generate_api_key()

key_obj = await ApiKey.create(
team=team,
label=data.label,
api_key_hash=key_hash,
api_key_prefix=prefix,
)

return ApiKeyWithSecret(
id=key_obj.id,
team_id=team_id,
label=key_obj.label,
api_key_prefix=key_obj.api_key_prefix,
created_at=key_obj.created_at,
api_key=full_key,
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Manual key creation could hit an unhandled IntegrityError on concurrent duplicate.

Lines 230-232 pre-check for a duplicate hash, but there's a TOCTOU window — a concurrent request with the same key could pass the check and then fail on the UNIQUE constraint at insert time, producing a raw 500. Consider catching IntegrityError around the create call.

Wrap create in IntegrityError handler
+    from tortoise.exceptions import IntegrityError
+
+    try:
         key_obj = await ApiKey.create(
             team=team,
             label=data.label,
             api_key_hash=key_hash,
             api_key_prefix=prefix,
         )
+    except IntegrityError:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="This API key already exists",
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@router.post("/teams/{team_id}/api-keys", response_model=ApiKeyWithSecret, status_code=status.HTTP_201_CREATED)
async def create_api_key(admin: AdminUser, team_id: UUID, data: ApiKeyCreate):
"""Create a new API key for a team. If api_key is provided, use it; otherwise auto-generate."""
team = await Team.filter(id=team_id).first()
if team is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team not found")
if data.api_key:
# Manually provided key
full_key = data.api_key
key_hash = ApiKey.hash_api_key(full_key)
# Derive prefix from the key (first 10 chars or less)
prefix = full_key[:10] if len(full_key) > 10 else full_key
# Check for duplicate hash
existing = await ApiKey.filter(api_key_hash=key_hash).first()
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="This API key already exists")
else:
# Auto-generate
full_key, key_hash, prefix = ApiKey.generate_api_key()
key_obj = await ApiKey.create(
team=team,
label=data.label,
api_key_hash=key_hash,
api_key_prefix=prefix,
)
return ApiKeyWithSecret(
id=key_obj.id,
team_id=team_id,
label=key_obj.label,
api_key_prefix=key_obj.api_key_prefix,
created_at=key_obj.created_at,
api_key=full_key,
)
`@router.post`("/teams/{team_id}/api-keys", response_model=ApiKeyWithSecret, status_code=status.HTTP_201_CREATED)
async def create_api_key(admin: AdminUser, team_id: UUID, data: ApiKeyCreate):
"""Create a new API key for a team. If api_key is provided, use it; otherwise auto-generate."""
team = await Team.filter(id=team_id).first()
if team is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Team not found")
if data.api_key:
# Manually provided key
full_key = data.api_key
key_hash = ApiKey.hash_api_key(full_key)
# Derive prefix from the key (first 10 chars or less)
prefix = full_key[:10] if len(full_key) > 10 else full_key
# Check for duplicate hash
existing = await ApiKey.filter(api_key_hash=key_hash).first()
if existing:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="This API key already exists")
else:
# Auto-generate
full_key, key_hash, prefix = ApiKey.generate_api_key()
from tortoise.exceptions import IntegrityError
try:
key_obj = await ApiKey.create(
team=team,
label=data.label,
api_key_hash=key_hash,
api_key_prefix=prefix,
)
except IntegrityError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This API key already exists",
)
return ApiKeyWithSecret(
id=key_obj.id,
team_id=team_id,
label=key_obj.label,
api_key_prefix=key_obj.api_key_prefix,
created_at=key_obj.created_at,
api_key=full_key,
)
🧰 Tools
🪛 Ruff (0.15.1)

[warning] 216-216: Unused function argument: admin

(ARG001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/api/admin.py` around lines 215 - 251, The create_api_key endpoint
currently does a pre-check for duplicate api_key_hash but can still hit a UNIQUE
constraint race; wrap the ApiKey.create call in a try/except that catches the
database IntegrityError (from your ORM/DB driver), and on IntegrityError
translate it into a HTTPException with status_code 400 and a clear message like
"This API key already exists"; keep the existing pre-check but ensure the except
path handles the concurrent-insert case for ApiKey.create so duplicate hash
insertion doesn't produce a raw 500.

Use teamsStore.getApiKeys/createApiKey/deleteApiKey instead of direct
api calls. Add empty-key guard for manual mode and show the secret
dialog based on backend response rather than UI mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@9cb14c1ec0 9cb14c1ec0 merged commit b58f235 into master Feb 19, 2026
3 checks passed
@9cb14c1ec0 9cb14c1ec0 deleted the api-key branch February 19, 2026 15:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant